iT邦幫忙

2026 iThome 鐵人賽

DAY 4
0
Modern Web

Angular 工程師的 React 陣痛期:30 天心智模型重建系列 第 4

Day 4 | 口袋少了生命週期鉤子,換成 useEffect 的陷阱

  • 分享至 

  • xImage
  •  

觸發停不下來

寫了一個檔案列表,call api後需要重新渲染。看起來很無害:

function FileList({ page }: { page: number }) {
  const [files, setFiles] = useState<File[]>([]);
  const params = { page, size: 20 };

  useEffect(() => {
    api.getFiles(params).then(setFiles);
  }, [params]);

  return <FileTable files={files} />;
}

跑起來,Network 面板開始像瀑布一樣往下流。同一支 API,一秒鐘重複多次,沒有停下來的跡象。

這在 Angular 也是寫得出來的錯,但只能說較少會遇到——因為 ngOnInit 根本不給你機會這樣寫。


一、Angular 的生命週期:框架幫你控制時段

Angular 的做法很直白:它在元件的一生中設了幾個時間點,你想在哪個點做事,就實作對應的介面。

export class FileListComponent implements OnInit, OnDestroy {
  ngOnInit() {
    // 元件初始化完成,input 已經第一次設定好
  }

  ngOnChanges(changes: SimpleChanges) {
    // 每次 input 變動
  }

  ngAfterViewInit() {
    // 子元件與 DOM 都就緒了
  }

  ngOnDestroy() {
    // 元件要被銷毀了
  }
}

這套設計有三個特徵:

每個時機都有名字。 你不用思考「現在是什麼階段」,方法名稱已經告訴你了。而且很直白!

順序是固定的。 ngOnChangesngOnInitngAfterViewInitngOnDestroy,框架照這個順序呼叫,不會變。

你回答的是「什麼時候」。 整套心智模型建立在時間軸上:元件出生、更新、死亡,你在對應的點掛上程式碼。

這也是為什麼 Angular 工程師轉 React 時,第一個問題永遠是那句:

所以 ngOnInit 跟其他生命週期對應到什麼?


二、React 沒有生命週期概念

答案是:沒有對應的東西。不是藏起來了,是這個概念被拿掉了

useEffect 看起來很像生命週期鉤子沒錯啦,但它的設計意圖完全不同。官方文件對它的定義是:把元件跟外部系統同步

差別在於回答的問題變了:

要回答的問題
Angular 什麼時候做這件事?
React 這件事依賴什麼

useEffect 的第二個參數不是「執行時機」,是依賴清單。你列出這個副作用用到了哪些值,React 負責在那些值變動時重新同步。至於它在元件生命中的哪個階段跑,你不需要知道,也不應該在意。

那張人人都會查的對照表

useEffect(() => { ... }, []);           // ≈ ngOnInit
useEffect(() => { ... }, [value]);      // ≈ ngOnChanges(但只看 value)
useEffect(() => { ... });               // 每次 render 後都跑
useEffect(() => { return () => {...} }, []); // ≈ ngOnDestroy

這張表是拐杖可以協助我們,但不是真相。 它能幫你撐過第一週,但你愈用它,愈寫不出正確的 React——因為它把你留在舊有「時機」的思維裡,而 bug 全都藏在「依賴」那一側。

剛剛上面列表提到的無限迴圈,就是這麼來的。


三、那個無限迴圈

回頭看那段程式碼:

const params = { page, size: 20 };

useEffect(() => {
  api.getFiles(params).then(setFiles);
}, [params]);

我的想法是:「params 變了才重新抓資料。」

但 React 比較依賴項的方式是 Object.is——也就是參考比較,不是內容比較。

params 是一個物件字面量,寫在元件函式本體裡。每一次 render,這行都會重新執行,產生一個全新的物件

{ page: 1, size: 20 } === { page: 1, size: 20 }   // false

內容一模一樣,但 React 只看參考,所以每次都判定「依賴變了」。

於是迴圈成形:

render → 建立新的 params → 依賴變了 → 跑 effect
      → setFiles → 觸發 render → 建立新的 params → ...

沒有任何一步是錯的。React 完全照我寫的做——是我以為自己在比較內容,實際上在比較身分。

三種修法

// 1. 搬進 effect 裡(最推薦)
useEffect(() => {
  api.getFiles({ page, size: 20 }).then(setFiles);
}, [page]);

// 2. 拆成原始值,讓比較退化成值比較
useEffect(() => {
  api.getFiles({ page, size }).then(setFiles);
}, [page, size]);

// 3. 真的需要保留物件時,才用 useMemo 固定參考
const params = useMemo(() => ({ page, size: 20 }), [page]);

第一種最好。依賴陣列裡只放原始值(字串、數字、布林),是我現在的硬規則——物件、陣列、函式一旦出現在裡面,就要停下來想三秒。

這也適用於函式:寫在元件裡的函式每次 render 也是新的參考,所以 useCallback 存在的理由跟 useMemo 一樣,不是效能,是參考穩定

還記得 Day 2 那個 useMemo(() => new ApiService(), []) 嗎?同一件事的不同面貌。參考相等性是 React 的地基,而 Angular 把它埋起來了。


四、清除函式:ngOnDestroy 只說對了一半

useEffect 可以回傳一個函式,多數人第一次看到會直接對應到 ngOnDestroy

useEffect(() => {
  const timer = setInterval(tick, 1000);
  return () => clearInterval(timer);
}, []);

但它不只在元件卸載時執行。每一次 effect 要重跑之前,上一次的清除函式都會先跑一遍。

useEffect(() => {
  const ws = connect(roomId);
  return () => ws.close();
}, [roomId]);

roomId 從 A 換成 B,順序是:關掉 A 的連線 → 建立 B 的連線。舊的一定會被收乾淨,你不用自己寫「如果已經有連線就先關掉」那段判斷。

這個概念 Angular 沒有對應物。ngOnDestroy 只在死亡時呼叫一次,中途的 input 變動要自己在 ngOnChanges 裡手動清理——這正是 takeUntilDestroyed 跟一堆 unsubscribe 樣板存在的原因。

所以更準確的說法是:清除函式不是「解構子」,是「復原上一次同步」。 想通這件事,useEffect 的設計就講得通了——它從頭到尾在做的就是「維持同步」,而不是「在某個時機做某件事」。

順帶一提:開發模式下你會發現 effect 跑了兩次、連線建了兩條。那是 StrictMode 故意的,用意就是逼你把清除函式寫對。這個坑留到 Week 4 講 WebSocket 時細講。


五、其實 Angular v20 也有一樣的東西

有趣的是,Signals 時代的 Angular 走了一樣的路:

export class FileListComponent {
  page = input.required<number>();

  constructor() {
    effect(() => {
      this.loadFiles(this.page());   // 讀到 page(),就自動建立依賴
    });
  }
}

effect() 也是「依賴變了就重跑」,不是「在某個時機執行」。概念跟 useEffect 一致。
之前有跟前輩聊過,其實現在框架互相概念會有不少抄來抄去。

但有一個關鍵差異:Angular 的依賴是自動追蹤的。 你在 effect 裡讀了哪些 signal,框架就記得哪些;React 要你手動列出來,列錯了就是 bug——漏列會拿到過期的值,多列會多跑,列了不穩定的參考就會無限迴圈。


今天的結論

ngOnInit 不是不見了,是整個以時間為軸的心智模型被換掉了。

Angular 問你「什麼時候做」,你回答一個時機。React 問你「這件事依賴什麼」,你回答一份清單——然後由框架決定什麼時候跑。

這個轉換最難的地方不在語法,在於你得放棄「元件有生命階段」這個直覺。只要還在腦中把 useEffect(fn, []) 翻譯成 ngOnInit,你遲早會寫出那個無限迴圈——因為你在回答一個 React 沒有問的問題。

而背後真正的分水嶺還是 JavaScript 本身:Object.is、參考相等性、每次 render 重新產生的字面量。這些在 Angular 裡從來不需要想,因為模板綁定跟生命週期把它們全包起來了。到了 React,它們直接決定你的程式會不會把瀏覽器燒起來。

Angular 幫你管時間,React 要你管依賴。 管不好的代價,就會變成一個轉個不停的迴圈風扇。

明天聊變更偵測:沒有 Zone.js,也沒有 Signals,React 到底怎麼知道畫面該重畫。


上一篇
Day 3|從 @if/@for 到 JSX:畫面交還給 JavaScript
下一篇
Day 5:沒有 Zone.js,也沒有 Signals:React 怎麼知道要重畫
系列文
Angular 工程師的 React 陣痛期:30 天心智模型重建9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言